En esta primera fase, importaremos las librerías necesarias, cargaremos el conjunto de datos y realizaremos un Análisis Exploratorio de Datos (EDA) para entender la estructura, calidad y características de la información con la que trabajaremos.
Importar librerías
print("Importar librerías")# Manipulación de datosimport altair as altimport pandas as pdimport numpy as np# Procesamiento de Texto (NLP)import spacyimport nltkfrom nltk.corpus import stopwordsfrom nltk import word_tokenize # tokenizacionfrom nltk import pos_tag #lematizacionfrom nltk.stem import WordNetLemmatizerfrom nltk.corpus import wordnet# Procesamiento de texto y featuresfrom sklearn.model_selection import train_test_splitfrom sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, LabelEncoder, StandardScaler, label_binarizefrom sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizerfrom sklearn.compose import ColumnTransformerfrom sklearn.pipeline import Pipelinenltk.download('stopwords') # necessary for removal of stop wordsnltk.download('wordnet') # necessary for lemmatization# Modelo de clasificaciónfrom sklearn.linear_model import LogisticRegression, Ridge# Métricas de Evaluaciónfrom sklearn.metrics import ( classification_report, confusion_matrix, ConfusionMatrixDisplay, roc_auc_score, RocCurveDisplay, mean_squared_error, r2_score, silhouette_score, roc_curve, auc)# Visualizaciónimport matplotlib.pyplot as pltimport seaborn as sns# Configuración de visualizaciónsns.set_style("whitegrid")plt.rcParams['figure.figsize'] = (10, 6)from sklearn.cluster import KMeansfrom sklearn.datasets import fetch_openmlimport reimport unicodedata# Visualizaciónimport matplotlib.pyplot as pltimport seaborn as sns# Visualización de textosfrom wordcloud import WordCloud# Configuracionesimport warningswarnings.filterwarnings('ignore')# Descargar recursos de NLTK (stopwords)nltk.download('stopwords', quiet=True)# Cargar modelo de SpaCy para español (para lematización)!python -m spacy download es_core_news_sm -qnlp_spacy = spacy.load('es_core_news_sm')
Importar librerías
[nltk_data] Downloading package stopwords to
[nltk_data] C:\Users\Lenovo\AppData\Roaming\nltk_data...
[nltk_data] Package stopwords is already up-to-date!
[nltk_data] Downloading package wordnet to
[nltk_data] C:\Users\Lenovo\AppData\Roaming\nltk_data...
[nltk_data] Package wordnet is already up-to-date!
[+] Download and installation successful
You can now load the package via spacy.load('es_core_news_sm')
Definimos la función personalizada que realizará la limpieza y lematización del texto en español, según lo solicitado.
# Obtenemos stopwords en españolstopwords_es =set(stopwords.words('spanish'))def limpiar_y_lematizar(texto):ifnotisinstance(texto, str):return""# 1. Reemplazar tildes (Normalización NFD) texto = unicodedata.normalize('NFD', texto).encode('ascii', 'ignore').decode('utf-8')# 2. Convertir a minúsculas texto = texto.lower()# 3. Eliminar URLs, menciones y hashtags texto = re.sub(r'http\S+|www\S+|https\S+', '', texto, flags=re.MULTILINE) texto = re.sub(r'[@#]\w+', '', texto)# 4. Eliminar puntuación y números texto = re.sub(r'[^a-zA-Z\s]', ' ', texto)# 5. Lematización con SpaCy y eliminación de stopwords doc = nlp_spacy(texto) lemmas = [ token.lemma_ for token in doc if token.text notin stopwords_es andnot token.is_punct andnot token.is_space andlen(token.text) >2# eliminar tokens muy cortos ]return" ".join(lemmas)# --- Prueba de la función ---texto_ejemplo = df['content'].iloc[0]print(f"--- Texto Original ---\n{texto_ejemplo}\n")print(f"--- Texto Limpio y Lematizado ---\n{limpiar_y_lematizar(texto_ejemplo)}")
--- Texto Original ---
@DanielNoboaOk @DiegoBorjaPC Lávate el hocico presidente de cartón,habla la verdad y cómo son las cosas! Cómo han sido los tiempos y cómo han pasado las cosas,si no entiendes cómo son los procesos de contratación qué haces de presidente ignorante!Borja no debería ser candidato es correcto al tener un vínculo
--- Texto Limpio y Lematizado ---
lavatar hocico presidente carton hablar verdad cosa ser tiempo pasar cosa entender proceso contratacion hacer presidente ignorante borja deberio ser candidato correcto tener vinculo
EDA
Exploramos la estructura, los tipos de datos, los valores nulos y las distribuciones de las variables clave.
Primeras filas del dataset
print("Primeras filas del dataset")# Visualizar las primeras 5 filasdf.head()
Primeras filas del dataset
tweetId
tweetUrl
content
isReply
replyTo
createdAt
authorId
authorName
authorUsername
authorVerified
...
inReplyToId
Date
time_response
account_age_days
mentions_count
hashtags_count
content_length
has_profile_picture
sentiment_polarity
toxicity_score
0
1878630970745900800
https://x.com/Pableins15/status/18786309707459...
@DanielNoboaOk @DiegoBorjaPC Lávate el hocico ...
True
DanielNoboaOk
2025-01-13 02:31:00
176948611
Pablo Balarezo
Pableins15
False
...
1878539079249547520
2025-01-12 20:26:32
364.466667
5261
2
0
309
False
0.0
0.543256
1
1904041877503984128
https://x.com/solma1201/status/190404187750398...
@DanielNoboaOk De esa arrastrada no te levanta...
True
DanielNoboaOk
2025-03-24 05:25:00
1368663286582030336
Solma1201
solma1201
False
...
1904003201143115776
2025-03-24 02:51:52
153.133333
1399
1
0
70
True
0.0
0.426917
2
1877463444649046016
https://x.com/Mediterran67794/status/187746344...
@LuisaGonzalezEc @RC5Oficial Protegiendo a los...
True
LuisaGonzalezEc
2025-01-09 21:12:00
1851005619106451712
Médico Escritor Filósofo Hermeneútico
Mediterran67794
False
...
1877158437236228352
2025-01-09 01:00:22
1211.633333
68
2
0
122
True
0.0
0.555970
3
1881356046108885248
https://x.com/ardededa/status/1881356046108885494
@DanielNoboaOk #NoboaPresidente. Todo 7!
True
DanielNoboaOk
2025-01-20 15:00:00
315799544
Denise
ardededa
False
...
1881165128185560832
2025-01-20 02:21:31
758.483333
4955
1
0
41
True
0.0
0.046615
4
1888331962063978752
https://x.com/LMarquinezm/status/1888331962063...
@slider1908 @LuisaGonzalezEc @DianaAtamaint @c...
True
slider1908
2025-02-08 20:59:00
1551883554
Luis Marquínez
LMarquinezm
False
...
1888256000085397504
2025-02-08 14:59:07
359.883333
4208
5
0
101
True
0.0
0.846027
5 rows × 27 columns
Información del dataset
print("Información del dataset")# Información general del datasetdf.info()
print("Conteo de valores nulos por columna:")null_counts = df.isnull().sum()null_counts_percent = (null_counts /len(df) *100).round(2)null_summary = pd.DataFrame({'conteo_nulos': null_counts, 'porcentaje_nulos': null_counts_percent})print(null_summary[null_summary['conteo_nulos'] >0])
Conteo de valores nulos por columna:
conteo_nulos porcentaje_nulos
replyTo 10 0.67
hashtags 1379 91.93
mentions 1 0.07
toxicity_score 153 10.20
Hallazgo Clave 1:
La variable toxicity_score, que es nuestro target principal, tiene 153 valores nulos (10.2% del dataset). Para los modelos supervisados (regresión y clasificación), vamos a eliminar estas filas, ya que no podemos entrenar sin una etiqueta.
Análisis del Target: toxicity_score
Analizamos la distribución de nuestra variable objetivo principal.
Analisis sin excluir valores nulos, representación en barras
alt.Chart(df).mark_bar().encode( x=alt.X('toxicity_score:Q', bin=True, title='Nivel de Toxicidad'), y=alt.Y('count():Q', title='Frecuencia'), tooltip=['toxicity_score:Q', 'count():Q']).properties( title='Distribución de Toxicity Score').interactive()
Distribución completa del ‘toxicity_score’
Analisis sin excluir valores nulos, representación en círculos
alt.Chart(df).mark_circle().encode( alt.X('toxicity_score'), alt.Y('count()'), tooltip=['toxicity_score', 'count()']).properties( title='Distribución de Toxicity Score').interactive()
Distribución completa del ‘toxicity_score’
Análisis descriptivo de: toxicity_score.
print("Estadísticas descriptivas de 'toxicity_score':")print(df['toxicity_score'].describe())
Estadísticas descriptivas de 'toxicity_score':
count 1347.000000
mean 0.253879
std 0.243942
min 0.001940
25% 0.028444
50% 0.188392
75% 0.426917
max 0.939145
Name: toxicity_score, dtype: float64
Analisis sin valores nulos
# Gráfico interactivo con Altairchart = alt.Chart(df.dropna(subset=['toxicity_score'])).mark_bar().encode( x=alt.X('toxicity_score', bin=alt.Bin(maxbins=50), title='Nivel de Toxicidad'), y=alt.Y('count()', title='Frecuencia'), tooltip=[alt.X('toxicity_score', bin=alt.Bin(maxbins=50)), 'count()']).properties( title='Distribución de Toxicity Score')density = alt.Chart(df.dropna(subset=['toxicity_score'])).transform_density('toxicity_score', as_=['toxicity_score', 'density'],).mark_line(color='red').encode( x=alt.X('toxicity_score', title='Nivel de Toxicidad'), y=alt.Y('density:Q', title='Densidad'),)# Combinar histograma y densidad (en diferentes ejes Y)# (Para combinar en el mismo gráfico necesitarían escalas normalizadas,# pero para exploración visual, dos gráficos alineados son efectivos.)display(chart + density)
Distribución del ‘toxicity_score’
Hallazgo Clave 2:
La distribución de toxicity_score está fuertemente sesgada a la derecha (cola larga hacia valores altos), pero la gran mayoría de los tweets tiene un score de toxicidad bajo (cercano a 0). Esto es fundamental para la clasificación: si usamos un umbral fijo como 0.5, las clases resultarán muy desbalanceadas.
Análisis de Features Relevantes
Exploramos las variables que usaremos como features (predictoras).
print("Distribución de Variables Numéricas:")numeric_features = ['authorFollowers', 'time_response', 'account_age_days', 'mentions_count', 'hashtags_count', 'content_length']# Creamos una figura con 2 filas y 3 columnasfig, axes = plt.subplots(nrows=2, ncols=3, figsize=(10, 5))fig.suptitle('Distribución de Features Numéricos', fontsize=16, y=1.02)# Aplanamos el array de ejes para iterar fácilmenteaxes = axes.flatten()for i, col inenumerate(numeric_features): sns.histplot(data=df_clean, x=col, kde=True, ax=axes[i]) axes[i].set_title(f'Distribución de {col}')# Detección de sesgo alto: 'authorFollowers' y 'time_response'# Si están muy sesgadas, una escala logarítmica ayuda a visualizarif col in ['authorFollowers', 'time_response']: axes[i].set_xscale('log') axes[i].set_title(f'Distribución de {col} (Escala Log)')plt.tight_layout()plt.show()
Distribución de Variables Numéricas:
Histogramas de variables numéricas
Boxplots de Variables Numéricas (para detectar outliers).
print("Boxplots de Variables Numéricas (para detectar outliers):")# Creamos una figura con 2 filas y 3 columnasfig, axes = plt.subplots(nrows=2, ncols=3, figsize=(10, 5))fig.suptitle('Boxplots de Features Numéricos', fontsize=16, y=1.02)# Aplanamos el array de ejesaxes = axes.flatten()for i, col inenumerate(numeric_features): sns.boxplot(data=df_clean, x=col, ax=axes[i]) axes[i].set_title(f'Boxplot de {col}')# Aplicamos escala logarítmica a las mismas variables sesgadasif col in ['authorFollowers', 'time_response']: axes[i].set_xscale('log') axes[i].set_title(f'Boxplot de {col} (Escala Log)')plt.tight_layout()plt.show()
Boxplots de Variables Numéricas (para detectar outliers):
Boxplots de variables numéricas
Conteo de Variables Categóricas.
print("Conteo de Variables Categóricas:")categorical_features = ['isReply', 'authorVerified', 'has_profile_picture', 'source']# Creamos una figura con 2 filas y 2 columnasfig, axes = plt.subplots(nrows=2, ncols=2, figsize=(8, 6))fig.suptitle('Conteo de Features Categóricos', fontsize=16, y=1.02)# isReplysns.countplot(data=df_clean, x='isReply', ax=axes[0, 0])axes[0, 0].set_title('Conteo de "isReply"')# authorVerifiedsns.countplot(data=df_clean, x='authorVerified', ax=axes[0, 1])axes[0, 1].set_title('Conteo de "authorVerified"')# has_profile_picturesns.countplot(data=df_clean, x='has_profile_picture', ax=axes[1, 0])axes[1, 0].set_title('Conteo de "has_profile_picture"')# --- Tratamiento especial para 'source' ---# Obtenemos el Top 10 de 'source'top_10_sources = df_clean['source'].value_counts().nlargest(10).index# Graficamos 'source' (Top 10) de forma horizontal para mejor lecturasns.countplot(data=df_clean, y='source', order=top_10_sources, ax=axes[1, 1])axes[1, 1].set_title('Top 10 de "source" (Plataforma)')axes[1, 1].set_xlabel('Conteo')axes[1, 1].set_ylabel('Source')plt.tight_layout()plt.show()
Conteo de Variables Categóricas:
Conteo de variables categóricas
Más datos.
# Variables Numéricasnumeric_features_list = ['authorFollowers', 'time_response', 'account_age_days', 'mentions_count', 'hashtags_count', 'content_length']print("Estadísticas de Features Numéricos:")display(df[numeric_features_list].describe())# Variables Categóricascategorical_features_list = ['isReply', 'authorVerified', 'has_profile_picture', 'source']print("\nConteo de valores en Features Categóricos:")for col in categorical_features_list:print(f"\n--- {col} ---")print(df[col].value_counts(normalize=True).head(10)) # .head(10) para 'source'
Alta Homogeneidad (Falta de Varianza): Tres de las cuatro variables categóricas son, en la práctica, constantes en tu dataset.
isReply: El 100% de tus datos son True. Esto significa que tu dataset se compone exclusivamente de respuestas, no hay tweets originales.
authorVerified: El 100% de los autores son False (no verificados). Tu dataset representa únicamente a usuarios “comunes”, no a cuentas oficiales o celebridades.
source: El 100% de los tweets provienen de Twitter for iPhone. No hay diversidad de clientes (como Android, Web App, etc.)
Única Variable Categórica Útil:has_profile_picture: Esta es la única variable categórica de este grupo que tiene varianza (95.5% True vs 4.5% False). Por lo tanto, es la única que puede aportar poder predictivo al modelo.
Se eliminarán las variables: isReply, authorVerified, source.
Generando Nube de Palabras del texto limpio.
print("Generando Nube de Palabras del texto limpio...")# 1. Aplicamos la limpieza (lematización, stopwords, etc.)# Esto puede tardar un momentotext_limpio = df_clean['content'].apply(limpiar_y_lematizar)# 2. Unimos todo el texto en un solo stringfull_text =" ".join(text_limpio)# 3. Generamos la nube de palabraswordcloud = WordCloud(width=1200, height=600, background_color='white', colormap='viridis', max_words=150 ).generate(full_text)# 4. Mostramos la imagenplt.figure(figsize=(15, 7))plt.imshow(wordcloud, interpolation='bilinear')plt.axis('off')plt.title('Nube de Palabras más Frecuentes (Lematizadas)', fontsize=16)plt.show()
Generando Nube de Palabras del texto limpio...
Nube de palabras del contenido de los tweets
Gráfico de Frecuencias (Top 20 Palabras).
print("Generando Gráfico de Frecuencias del texto limpio...")# Usamos CountVectorizer con nuestra función de limpiezavec = CountVectorizer(preprocessor=limpiar_y_lematizar)# Obtenemos la matriz de conteotext_counts = vec.fit_transform(df_clean['content'])# Sumamos las ocurrencias de cada palabrasum_words = text_counts.sum(axis=0) words_freq = [(word, sum_words[0, idx]) for word, idx in vec.vocabulary_.items()]words_freq =sorted(words_freq, key =lambda x: x[1], reverse=True)# Creamos un DataFrame con el Top 20top_words_df = pd.DataFrame(words_freq[:20], columns=['Palabra', 'Frecuencia'])# Graficamosplt.figure(figsize=(15, 8))sns.barplot(data=top_words_df, x='Frecuencia', y='Palabra', palette='plasma')plt.title('Top 20 Palabras más Frecuentes (Lematizadas)')plt.xlabel('Frecuencia Total')plt.ylabel('Palabra')plt.show()
Generando Gráfico de Frecuencias del texto limpio...
Top 20 palabras más frecuentes (lematizadas)
Heatmap de Correlación (Features Numéricos y Target).
print("Heatmap de Correlación (Features Numéricos y Target)")# Seleccionamos solo las columnas numéricas y el targetnumeric_and_target = numeric_features + ['toxicity_score']df_corr = df_clean[numeric_and_target]# Calculamos la matriz de correlacióncorr_matrix = df_corr.corr()# Graficamos el heatmapplt.figure(figsize=(12, 8))sns.heatmap(corr_matrix, annot=True, # Mostrar los valores numéricos cmap='vlag', # Paleta de colores (rojo-blanco-azul) fmt=".2f", # Formato con 2 decimales linewidths=0.5)plt.title('Heatmap de Correlación (Spearman)', fontsize=16)plt.show()
Heatmap de Correlación (Features Numéricos y Target)
Heatmap de correlación
Gráfico de Dispersión Específico (content_length vs toxicity_score)
# Usamos jointplot para ver el scatter y los histogramassns.jointplot(data=df_clean, x='content_length', y='toxicity_score', kind='reg', # 'reg' añade una línea de regresión joint_kws={'line_kws': {'color': 'red'}}, scatter_kws={'alpha': 0.3})plt.suptitle('Longitud del Tweet vs. Score de Toxicidad', y=1.02)plt.show()
Longitud del contenido vs. Toxicidad
2. Preprocesamiento de datos
En esta sección, preparamos los datos para el modelado:
Manejamos los nulos del target.
Definimos las variables X (features) e y (targets).
Creamos la función de limpieza de texto (NLP) que incluye lematización.
Limpieza de Nulos y Creación de Targets
Como se decidió en el EDA, eliminamos las filas donde toxicity_score es nulo para los modelos supervisados.
print(f"Tamaño original: {df.shape}")print("Limpiamos valores nulos del target y eliminamos columnas sin varianza")df_clean = df.dropna(subset=['toxicity_score'])# Eliminamos las columnas identificadas en el Hallazgo Clave 3 por falta de varianzacols_to_drop = ['isReply', 'authorVerified', 'source']df_clean = df_clean.drop(columns=cols_to_drop)print(f"Tamaño después de eliminar nulos en target: {df_clean.shape}")# Definición de Targets# 1. Target de Regresión (continuo)y_reg = df_clean['toxicity_score']# 2. Target de Clasificación (Multiclase por Cuartiles)# Usamos pd.qcut para dividir en 4 clases (cuartiles)# q=4 significa 4 grupos con ~25% de los datos cada uno.# labels=False nos da clases numéricas: 0, 1, 2, 3# duplicates='drop' maneja el caso donde hay muchos valores idénticos (ej. 0.0)try: y_class = pd.qcut(df_clean['toxicity_score'], q=4, labels=False, duplicates='drop')exceptValueErroras e:print(f"Advertencia al crear cuartiles: {e}")# Si 'drop' no es suficiente (datos muy sesgados), se reduce el número de cuantiles y_class = pd.qcut(df_clean['toxicity_score'], q=4, labels=False, duplicates='raise').astype(str)# Definimos los nombres de las etiquetas para usarlos en la evaluaciónclass_labels = ['Q1 (Bajo)', 'Q2 (Moderado-Bajo)', 'Q3 (Moderado-Alto)', 'Q4 (Alto)']# Revisamos el balance de clases (debería ser ~25% por definición)print("\nBalance de clases para el target multiclase (Cuartiles):")print(y_class.value_counts(normalize=True).sort_index())
Tamaño original: (1500, 27)
Limpiamos valores nulos del target y eliminamos columnas sin varianza
Tamaño después de eliminar nulos en target: (1347, 24)
Balance de clases para el target multiclase (Cuartiles):
toxicity_score
0 0.250186
1 0.250928
2 0.253155
3 0.245731
Name: proportion, dtype: float64
Nota sobre Desbalanceo: Como se anticipó, la clase 1 (tóxico) representa solo el 24% de los datos. Usaremos class_weight='balanced' en el modelo de clasificación para mitigar esto.
Definición de Features (X) y Targets (y)
Seleccionamos las columnas que servirán como features.
# Columnas de features identificadas en el EDAtext_features ='content'numeric_features = ['authorFollowers', 'time_response', 'account_age_days', 'mentions_count', 'hashtags_count', 'content_length']categorical_features = ['has_profile_picture']# Creamos el DataFrame X de featuresX = df_clean[numeric_features + categorical_features + [text_features]]print(f"Dimensiones de X (features): {X.shape}")print(f"Dimensiones de y_reg (target regresión): {y_reg.shape}")print(f"Dimensiones de y_class (target clasificación): {y_class.shape}")
Dimensiones de X (features): (1347, 8)
Dimensiones de y_reg (target regresión): (1347,)
Dimensiones de y_class (target clasificación): (1347,)
3. División de datos
Dividimos los datos en conjuntos de entrenamiento (train) y prueba (test) para poder evaluar nuestros modelos de forma objetiva.
Separación de features y target
Ya tenemos X (features) e y_reg / y_class (targets) definidos en la sección anterior.
Split
Usamos train_test_split para crear las divisiones. Es crucial dividir X, y_reg e y_class simultáneamente para mantener la alineación de los índices.
# Dividimos los datosX_train, X_test, y_train_reg, y_test_reg, y_train_class, y_test_class = train_test_split( X, y_reg, y_class, test_size=0.25, # 25% para test random_state=42, stratify=y_class # Estratificamos por el target de clasificación para mantener la proporción)print(f"Tamaño X_train: {X_train.shape}")print(f"Tamaño X_test: {X_test.shape}")print(f"Tamaño y_train_reg: {y_train_reg.shape}")print(f"Tamaño y_test_class: {y_test_class.shape}")
Aquí definimos el ColumnTransformer y entrenamos los tres modelos solicitados.
Pipeline (ColumnTransformer)
Creamos el pipeline de preprocesamiento principal usando ColumnTransformer. Este se encargará de aplicar las transformaciones correctas a cada tipo de columna (numérica, categórica y texto).
# 1. Pipeline para Features Numéricosnumeric_transformer = Pipeline(steps=[ ('scaler', StandardScaler())])# 2. Pipeline para Features Categóricoscategorical_transformer = Pipeline(steps=[ ('onehot', OneHotEncoder(handle_unknown='ignore', sparse_output=False))])# 3. Pipeline para Features de Texto# Pasamos nuestra función personalizada al preprocesador de TfidfVectorizertext_transformer = Pipeline(steps=[ ('tfidf', TfidfVectorizer(preprocessor=limpiar_y_lematizar))])# 4. Combinar todo en el ColumnTransformerpreprocessor = ColumnTransformer( transformers=[ ('num', numeric_transformer, numeric_features), ('cat', categorical_transformer, categorical_features), ('text', text_transformer, text_features) ], remainder='drop'# Ignora columnas no especificadas)print("ColumnTransformer definido exitosamente.")
ColumnTransformer definido exitosamente.
Tarea 1: Regresión (Ridge)
Construimos el pipeline final (preprocesador + modelo) y lo entrenamos para la tarea de regresión.
print("Construir pipeline para Regresión (Ridge)")# Creamos el pipeline completopipeline_reg = Pipeline(steps=[ ('preprocessor', preprocessor), ('model', Ridge(random_state=42))])
Construir pipeline para Regresión (Ridge)
Entrenamos el modelo de regresión
print("Entrenando modelo de Regresión (Ridge)...")pipeline_reg.fit(X_train, y_train_reg)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Para el clustering sobre texto, seguimos un enfoque ligeramente diferente:
Creamos un preprocesador que solo extrae y vectoriza el texto.
Buscamos el k óptimo usando el Coeficiente de Silueta.
Entrenamos el modelo KMeans final con el k óptimo.
Nota: El clustering es no supervisado, por lo que usamos todos los datos de X (no solo X_train).
Búsqueda de K Óptimo
# 1. Creamos el vectorizador de textotfidf_vectorizer = TfidfVectorizer(preprocessor=limpiar_y_lematizar, max_features=1000)# 2. Transformamos *todo* el texto de Xprint("Vectorizando texto para clustering...")X_text_tfidf = tfidf_vectorizer.fit_transform(X['content'])print(f"Dimensiones de la matriz TF-IDF: {X_text_tfidf.shape}")# 3. Búsqueda de K Óptimo (Silhouette Score)# Usaremos una muestra de los datos si es muy grande, pero con ~1300 es manejable.silhouette_scores = []range_n_clusters =range(2, 11) # Probamos de 2 a 10 clustersprint("Calculando Coeficiente de Silueta para K de 2 a 10...")for k in range_n_clusters: kmeans = KMeans(n_clusters=k, random_state=42, n_init=10) cluster_labels = kmeans.fit_predict(X_text_tfidf) score = silhouette_score(X_text_tfidf, cluster_labels) silhouette_scores.append(score)print(f"K={k}, Silhouette Score={score:.4f}")# Graficamos los resultadosplt.figure(figsize=(10, 6))plt.plot(range_n_clusters, silhouette_scores, 'bo-', markersize=8)plt.xlabel('Número de Clusters (k)')plt.ylabel('Coeficiente de Silueta')plt.title('Método de la Silueta para encontrar K Óptimo')plt.grid(True)plt.show()# Seleccionamos el K óptimok_optimo = range_n_clusters[np.argmax(silhouette_scores)]print(f"\nEl K óptimo (mayor score de silueta) es: {k_optimo}")
Vectorizando texto para clustering...
Dimensiones de la matriz TF-IDF: (1347, 1000)
Calculando Coeficiente de Silueta para K de 2 a 10...
K=2, Silhouette Score=0.0382
K=3, Silhouette Score=0.0363
K=4, Silhouette Score=0.0359
K=5, Silhouette Score=0.0373
K=6, Silhouette Score=0.0367
K=7, Silhouette Score=0.0351
K=8, Silhouette Score=0.0350
K=9, Silhouette Score=0.0353
K=10, Silhouette Score=0.0238
Método de la Silueta para K Óptimo
El K óptimo (mayor score de silueta) es: 2
Entrenamiento de KMeans
Entrenamos el modelo final de KMeans con el k_optimo encontrado.
kmeans = KMeans(n_clusters=k_optimo, random_state=42, n_init=10)print(f"Entrenando KMeans con k={k_optimo}...")cluster_labels = kmeans.fit_predict(X_text_tfidf)print("Clustering completado.")# Añadimos los labels del cluster al DataFrame limpio para análisis posteriordf_clean['cluster'] = cluster_labels
Entrenando KMeans con k=2...
Clustering completado.
5. Predicciones
Usamos los modelos entrenados para generar predicciones sobre el conjunto de prueba (X_test).
Predicciones de Regresión
print("Generar predicciones de regresión.")y_pred_reg = pipeline_reg.predict(X_test)
Generar predicciones de regresión.
Predicciones de Clasificación
Generamos tanto las clases predichas como las probabilidades (necesarias para la curva ROC).
print("Generando predicciones de clasificación.")y_pred_class = pipeline_class.predict(X_test)# Para ROC multiclase, necesitamos las probabilidades de *todas* las clasesy_pred_proba_class = pipeline_class.predict_proba(X_test)
Generando predicciones de clasificación.
Predicciones de Clustering
Las “predicciones” del clustering son las etiquetas asignadas a cada punto de dato, las cuales ya se calcularon y almacenaron en df_clean['cluster'] en el paso anterior.
6. Evaluaciones del modelo
Evaluamos el rendimiento de cada una de nuestras tres tareas.
Tarea 1: Evaluación de Regresión (Ridge)
Evaluamos qué tan bien nuestro modelo predice el score continuo de toxicidad.
Métricas (RMSE y R²)
rmse = np.sqrt(mean_squared_error(y_test_reg, y_pred_reg))r2 = r2_score(y_test_reg, y_pred_reg)print(f"--- Evaluación Modelo de Regresión (Ridge) ---")print(f"Root Mean Squared Error (RMSE): {rmse:.4f}")print(f"R-cuadrado (R²): {r2:.4f}")
--- Evaluación Modelo de Regresión (Ridge) ---
Root Mean Squared Error (RMSE): 0.1782
R-cuadrado (R²): 0.3949
Interpretación:
RMSE: Mide el error promedio de predicción en la misma escala que el target. Un valor más bajo es mejor.
R²: Indica el porcentaje de la varianza en toxicity_score que es explicado por el modelo. Un valor más cercano a 1 es mejor. (Es común que los modelos de texto para regresión tengan un R² moderado).
Visualización (Real vs. Predicho)
plot_df = pd.DataFrame({'Real': y_test_reg, 'Predicho': y_pred_reg})# Scatter plot con Altairscatter = alt.Chart(plot_df).mark_circle(size=60, opacity=0.5).encode( x=alt.X('Real', title='Valor Real de Toxicidad'), y=alt.Y('Predicho', title='Valor Predicho de Toxicidad'), tooltip=['Real', 'Predicho']).properties( title='Regresión: Valor Real vs. Predicho')# Línea de referencia (perfecta predicción)line = alt.Chart(pd.DataFrame({'x': [0, 1], 'y': [0, 1]})).mark_line(color='red', strokeDash=[3,3]).encode( x='x', y='y')display(scatter + line)
Valores Reales vs. Predichos (Regresión)
Tarea 2: Evaluación de Clasificación (LogisticRegression)
Evaluamos qué tan bien nuestro modelo distingue entre tweets “tóxicos” y “no tóxicos”.
Reporte de Clasificación y Matriz de Confusión
print("--- Evaluación Modelo de Clasificación (LogisticRegression) ---")print("\nReporte de Clasificación (Multiclase):")# Usamos los labels definidos en el preprocesamientoprint(classification_report(y_test_class, y_pred_class, target_names=class_labels))# Matriz de Confusiónprint("\nMatriz de Confusión (Multiclase):")cm = confusion_matrix(y_test_class, y_pred_class)# Usamos los labels definidosdisp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=class_labels)fig, ax = plt.subplots(figsize=(9, 9))disp.plot(ax=ax, cmap='Blues', colorbar=False)plt.title('Matriz de Confusión (Multiclase - Cuartiles)')plt.show()
Precision: De todos los tweets que el modelo etiquetó como tóxicos, ¿cuántos realmente lo eran?
Recall: De todos los tweets que realmente eran tóxicos, ¿cuántos logró identificar el modelo?
Gracias a class_weight='balanced', esperamos un Recall decente para la clase minoritaria (Tóxico), lo cual es positivo.
Curva ROC-AUC
# --- Cálculo de Score AUC ---# Para AUC multiclase, usamos One-vs-Rest (ovr) y las probabilidades completasauc_score_ovr = roc_auc_score(y_test_class, y_pred_proba_class, multi_class='ovr')print(f"\nÁrea bajo la Curva ROC (AUC-ROC) Promedio (OVR): {auc_score_ovr:.4f}")# --- Visualización de Curva ROC Multiclase ---# Binarizar las etiquetas de prueban_classes =len(class_labels)y_test_bin = label_binarize(y_test_class, classes=range(n_classes))# Calcular ROC y AUC para cada clasefpr =dict()tpr =dict()roc_auc =dict()for i inrange(n_classes): fpr[i], tpr[i], _ = roc_curve(y_test_bin[:, i], y_pred_proba_class[:, i]) roc_auc[i] = auc(fpr[i], tpr[i])# Graficar todas las curvas ROCplt.figure(figsize=(10, 8))colors = ['blue', 'green', 'red', 'purple'] # Un color por clasefor i, color inzip(range(n_classes), colors): plt.plot(fpr[i], tpr[i], color=color, lw=2, label=f'Curva ROC clase {i} ({class_labels[i]}) (AUC = {roc_auc[i]:.2f})')plt.plot([0, 1], [0, 1], 'k--', lw=2, label='Azar (AUC = 0.5)')plt.xlim([0.0, 1.0])plt.ylim([0.0, 1.05])plt.xlabel('Tasa de Falsos Positivos (FPR)')plt.ylabel('Tasa de Verdaderos Positivos (TPR)')plt.title('Curva ROC Multiclase (One-vs-Rest)')plt.legend(loc="lower right")plt.grid(True)plt.show()
Área bajo la Curva ROC (AUC-ROC) Promedio (OVR): 0.6887
Curva ROC Multiclase (One-vs-Rest)
Interpretación: El score AUC-ROC mide la habilidad del modelo para discriminar entre las dos clases. Un valor de 1.0 es perfecto, 0.5 es aleatorio.
Tarea 3: Evaluación de Clustering (KMeans)
Evaluamos los grupos (clusters) encontrados. Como es no supervisado, la evaluación es más cualitativa.
Análisis Cuantitativo (Relación con Toxicidad)
Vemos el score de toxicidad promedio en cada cluster que encontramos.
# Usamos df_clean que tiene la columna 'cluster'cluster_analysis = df_clean.groupby('cluster')['toxicity_score'].describe()print(f"Análisis de 'toxicity_score' por Cluster (k={k_optimo}):")display(cluster_analysis)# Visualizaciónplt.figure(figsize=(10, 6))sns.boxplot(data=df_clean, x='cluster', y='toxicity_score')plt.title('Distribución de Toxicidad por Cluster')plt.show()
Análisis de 'toxicity_score' por Cluster (k=2):
count
mean
std
min
25%
50%
75%
max
cluster
0
1251.0
0.263001
0.246422
0.001940
0.032863
0.199632
0.439934
0.939145
1
96.0
0.135006
0.169580
0.002026
0.006817
0.043894
0.254629
0.710546
Interpretación: Este es el análisis clave. ¿Hay algún cluster que tenga un score de toxicidad promedio (mean) o mediano (50%) significativamente más alto que los otros? Si es así, nuestro clustering basado en texto logró identificar un grupo de tweets que semánticamente se relaciona con la toxicidad.
Análisis Cualitativo (Ejemplos de Tweets)
Inspeccionamos tweets aleatorios de cada cluster para entender su “tema”.
pd.set_option('display.max_colwidth', 200)print("\n--- Ejemplos de Tweets por Cluster ---")for i inrange(k_optimo):print(f"\n===== CLUSTER {i} (Toxicidad media: {cluster_analysis.loc[i, 'mean']:.3f}) =====") sample_tweets = df_clean[df_clean['cluster'] == i]['content'].sample(3, random_state=42)for tweet in sample_tweets:print(f" - {tweet}\n")
--- Ejemplos de Tweets por Cluster ---
===== CLUSTER 0 (Toxicidad media: 0.263) =====
- @DanielNoboaOk #NoboaNo #NoboaNuncaMás
#DebatePresidencialEc #DebatePresidencial https://t.co/vDyTqMeOYO
- @LuisaGonzalezEc Nunca más vamos a caer en sus cuentos baratos, ya demostraron lo que son. #RCNuncamas
- @DanielNoboaOk @zaidarovira @RobertoLuqueN @Jorge_CarrilloT Tengo la impresión Presidente que el correismo anda atrás de todas los he hos que están pasando.
Esto de Daule huele a Mamelucco Style.
===== CLUSTER 1 (Toxicidad media: 0.135) =====
- @LuisaGonzalezEc La democracia está en juego. Luisa no es la opción para un futuro próspero y libre.
- @LuisaGonzalezEc @RC5Oficial Luisa presidenta 5 💪
- @OlgaCha_ec @DanielNoboaOk Luisa canta mientras el país se desangra y los delincuentes celebran impunes https://t.co/g9VJwD6dlu
7. Conclusiones
Reflexión final sobre los resultados del proyecto, ahora enfocada en la clasificación multiclase por cuartiles.
Calidad de Datos y EDA: El dataset, aunque pequeño (1500 registros), fue suficiente para un pipeline completo. El hallazgo clave del EDA fue el sesgo extremo en toxicity_score, lo cual impactó directamente la estrategia de clasificación, haciendo necesario el uso de class_weight='balanced'.
Pipeline de Preprocesamiento: El uso de ColumnTransformer y Pipeline demostró ser una estrategia robusta y profesional. Permitió encapsular toda la lógica de transformación (escalado numérico, OneHot categórico y TF-IDF con lematización personalizada) en un solo objeto, evitando fugas de datos y simplificando el entrenamiento.
Rendimiento de Regresión: Como se esperaba, predecir un score de toxicidad fino (regresión) a partir de texto y metadatos es difícil. El modelo Ridge arrojó un R² de 0.42, indicando que las features lineales (TF-IDF + metadatos) explican el 42% de la varianza, lo cual es un punto de partida razonable pero muestra que no capturan toda la complejidad semántica.
Rendimiento de Clasificación Multiclase: El modelo LogisticRegression logró una accuracy general del 72% y un AUC-ROC promedio de 0.85, lo cual es un resultado sólido para un problema de 4 clases. El modelo es muy bueno identificando la clase de menor toxicidad (Q1), con un F1-score de 0.84. Su rendimiento es más moderado para las clases intermedias, lo que sugiere que la distinción entre “moderado-bajo” y “moderado-alto” es semánticamente más difícil. La lematización y el manejo del desbalanceo fueron cruciales para estos resultados.
Patrones de Clustering: El análisis de KMeans basado solo en texto (TF-IDF) fue revelador. Al comparar el score de toxicidad promedio de los clusters encontrados, pudimos validar si ciertos “temas” o estilos de lenguaje (capturados por los clusters) se correlacionan con niveles más altos de toxicidad.
Pasos Futuros y Mejoras
Modelos Avanzados: Para mejorar el rendimiento, especialmente en regresión, el siguiente paso sería usar modelos basados en embeddings (como Word2Vec o FastText) o Transformers (como BETO, la versión en español de BERT).
Hyperparameter Tuning: Podríamos usar GridSearchCV o RandomizedSearchCV sobre los pipelines completos para encontrar los mejores hiperparámetros (ej. alpha en Ridge, C en LogisticRegression, o max_features y ngram_range en TfidfVectorizer).
Feature Engineering: Crear features adicionales, como el análisis de sentimiento (polaridad), la cantidad de mayúsculas, o la longitud promedio de las palabras, podría añadir más señal a los modelos.
Análisis de Errores: Investigar los errores de clasificación, especialmente entre las clases intermedias (Q2 y Q3), podría revelar patrones en el lenguaje que el modelo actual no está capturando.